--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 58d2fd55fcefea2b94d6a9a4981bdf61d431c60d
Parents : e999ff9
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-18T08:24:04-05:00
feat: update RNSH session environment setup for Landlock compatibility and improve WebGL visualiser icon handling
Changes
8 files changed, 433 insertions(+), 32 deletions(-)
Diff
diff --git a/meshchatx.rsm b/meshchatx.rsm
index a28e612d..99d7bf7e 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/src/backend/rnsh_manager.py b/meshchatx/src/backend/rnsh_manager.py
index c13f5203..6f2588d6 100644
--- a/meshchatx/src/backend/rnsh_manager.py
+++ b/meshchatx/src/backend/rnsh_manager.py
@@ -371,6 +371,26 @@ class RNSHSession:
def _supports_pty():
return _PTY_SUPPORTED
+ def _resolved_rnsh_home(self):
+ """Directory used as HOME for the rnsh subprocess.
+
+ Upstream rnsh always creates or opens ~/.rnsh (see ensure_config_directory).
+ Under Landlock the real home is not writable, so MeshChatX points HOME at
+ an identity-scoped path under storage that the sandbox already allows.
+ """
+ storage = getattr(self.manager, "storage_dir", None)
+ if isinstance(storage, str) and storage.strip():
+ return os.path.join(storage.strip(), "rnsh_home")
+ return ""
+
+ def _ensure_rnsh_home(self, home_dir):
+ """Create HOME and the ~/.rnsh layout rnsh expects before spawn."""
+ if not home_dir:
+ return ""
+ rnsh_cfg = os.path.join(home_dir, ".rnsh")
+ os.makedirs(rnsh_cfg, exist_ok=True)
+ return home_dir
+
def _build_env(self):
env = dict(os.environ)
env.setdefault("TERM", "xterm-256color")
@@ -379,6 +399,12 @@ class RNSHSession:
# Pipe mode (Windows and pytest) has no TTY, so CPython fully buffers
# stdout and listen-address lines never reach the reader promptly.
env["PYTHONUNBUFFERED"] = "1"
+ home_dir = self._ensure_rnsh_home(self._resolved_rnsh_home())
+ if home_dir:
+ # Keep rnsh's ensure_config_directory() inside the Landlock RW tree.
+ env["HOME"] = home_dir
+ # Avoid picking up a host XDG tree that Landlock cannot create under.
+ env.pop("XDG_CONFIG_HOME", None)
return env
@staticmethod
diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index ffd325e2..958cc4f8 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -1815,4 +1815,14 @@ export default {
background-image: radial-gradient(#18181b 1px, transparent 1px);
background-size: 32px 32px;
}
+
+#network-webgl,
+.network-webgl-labels {
+ image-rendering: auto;
+}
+
+.network-webgl-labels {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
</style>
diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGL.js b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
index 7ef0b375..c961cd91 100644
--- a/meshchatx/src/frontend/js/networkVisualiserWebGL.js
+++ b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
@@ -9,16 +9,16 @@ export const SCENE_NODE_STRIDE = 8;
export const NODE_STRIDE = 10;
export const EDGE_STRIDE = 8;
-const ATLAS_CELL = 64;
+const ATLAS_CELL = 128;
const ATLAS_COLS = 16;
const ATLAS_ROWS = 16;
const ATLAS_CAPACITY = ATLAS_COLS * ATLAS_ROWS;
/** Soft-edge AA width in UV radius units (1.0 = disc edge). Keep tight to avoid fuzzy blobs. */
-export const NODE_EDGE_INNER = 0.96;
+export const NODE_EDGE_INNER = 0.97;
/** Border ring starts inside the disc (untextured and under glyphs). */
-export const NODE_BORDER_INNER = 0.78;
-export const NODE_BORDER_OUTER = 0.96;
+export const NODE_BORDER_INNER = 0.82;
+export const NODE_BORDER_OUTER = 0.97;
const NODE_VS = `#version 300 es
layout(location=0) in vec2 a_corner;
@@ -238,7 +238,7 @@ export function isGlyphStyleVisualiserIcon(url) {
* Prepare atlas RGBA pixels for upload.
*
* - opaque: force non-empty pixels to a=255 (RGB PNG uploads)
- * - glyph: keep near-white / bright pixels as white glyphs, clear the solid fill
+ * - glyph: keep bright glyph coverage as soft alpha (preserves AA), clear fill
*
* @param {Uint8ClampedArray|Uint8Array} data RGBA buffer (mutated)
* @param {"opaque"|"glyph"} mode
@@ -257,23 +257,34 @@ export function prepareVisualiserIconPixels(data, mode = "opaque") {
if (!(r | g | b | a)) continue;
painted += 1;
if (glyphMode) {
- // Stock badges: bright glyph on saturated fill. Keep bright pixels.
+ // Soft coverage from luma so badge AA edges stay smooth when scaled.
const luma = 0.299 * r + 0.587 * g + 0.114 * b;
const maxc = Math.max(r, g, b);
const minc = Math.min(r, g, b);
const sat = maxc - minc;
- const isGlyph = luma >= 185 || (luma >= 150 && sat < 40);
- if (isGlyph) {
- data[i] = 255;
- data[i + 1] = 255;
- data[i + 2] = 255;
- data[i + 3] = 255;
- glyphPixels += 1;
- } else {
+ let cover = 0;
+ if (luma >= 210) {
+ cover = 1;
+ } else if (luma >= 150) {
+ cover = (luma - 150) / 60;
+ if (sat > 50) {
+ cover *= Math.max(0, 1 - (sat - 50) / 120);
+ }
+ } else if (luma >= 130 && sat < 35) {
+ cover = ((luma - 130) / 20) * 0.45;
+ }
+ if (cover <= 0.02) {
data[i] = 0;
data[i + 1] = 0;
data[i + 2] = 0;
data[i + 3] = 0;
+ } else {
+ const alpha = Math.round(Math.min(1, cover) * 255);
+ data[i] = 255;
+ data[i + 1] = 255;
+ data[i + 2] = 255;
+ data[i + 3] = alpha;
+ if (alpha >= 24) glyphPixels += 1;
}
} else {
data[i + 3] = 255;
@@ -287,11 +298,12 @@ function createIconAtlas(gl) {
const height = ATLAS_ROWS * ATLAS_CELL;
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
+ gl.generateMipmap(gl.TEXTURE_2D);
const urlToSlot = new Map();
const pending = new Map();
@@ -302,7 +314,13 @@ function createIconAtlas(gl) {
scratch.width = ATLAS_CELL;
scratch.height = ATLAS_CELL;
}
- const scratchCtx = scratch?.getContext?.("2d", { willReadFrequently: true }) || null;
+ const scratchCtx = scratch?.getContext?.("2d", { willReadFrequently: true, alpha: true }) || null;
+ if (scratchCtx) {
+ scratchCtx.imageSmoothingEnabled = true;
+ if ("imageSmoothingQuality" in scratchCtx) {
+ scratchCtx.imageSmoothingQuality = "high";
+ }
+ }
function allocSlot() {
if (freeSlots.length > 0) return freeSlots.pop();
@@ -314,6 +332,11 @@ function createIconAtlas(gl) {
if (!scratchCtx || !scratch) return false;
scratchCtx.save();
scratchCtx.setTransform(1, 0, 0, 1, 0, 0);
+ scratchCtx.globalCompositeOperation = "source-over";
+ scratchCtx.imageSmoothingEnabled = true;
+ if ("imageSmoothingQuality" in scratchCtx) {
+ scratchCtx.imageSmoothingQuality = "high";
+ }
scratchCtx.clearRect(0, 0, ATLAS_CELL, ATLAS_CELL);
const sw = source.width || source.videoWidth || ATLAS_CELL;
const sh = source.height || source.videoHeight || ATLAS_CELL;
@@ -321,9 +344,12 @@ function createIconAtlas(gl) {
scratchCtx.restore();
return false;
}
- const scale = Math.min(ATLAS_CELL / sw, ATLAS_CELL / sh);
- const dw = Math.max(1, Math.floor(sw * scale));
- const dh = Math.max(1, Math.floor(sh * scale));
+ // Pad slightly so circular badges do not clip AA fringes at the cell edge.
+ const pad = 2;
+ const fit = ATLAS_CELL - pad * 2;
+ const scale = Math.min(fit / sw, fit / sh);
+ const dw = Math.max(1, Math.round(sw * scale));
+ const dh = Math.max(1, Math.round(sh * scale));
const dx = Math.floor((ATLAS_CELL - dw) / 2);
const dy = Math.floor((ATLAS_CELL - dh) / 2);
scratchCtx.drawImage(source, dx, dy, dw, dh);
@@ -356,6 +382,7 @@ function createIconAtlas(gl) {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.texSubImage2D(gl.TEXTURE_2D, 0, col * ATLAS_CELL, row * ATLAS_CELL, gl.RGBA, gl.UNSIGNED_BYTE, scratch);
+ gl.generateMipmap(gl.TEXTURE_2D);
return true;
}
@@ -514,9 +541,16 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
if (typeof document !== "undefined" && canvas?.parentElement) {
labelCanvas = document.createElement("canvas");
labelCanvas.className = "network-webgl-labels";
- labelCanvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;";
+ labelCanvas.style.cssText =
+ "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;";
canvas.parentElement.appendChild(labelCanvas);
- labelCtx = labelCanvas.getContext("2d");
+ labelCtx = labelCanvas.getContext("2d", { alpha: true });
+ if (labelCtx) {
+ labelCtx.imageSmoothingEnabled = true;
+ if ("imageSmoothingQuality" in labelCtx) {
+ labelCtx.imageSmoothingQuality = "high";
+ }
+ }
}
function resize() {
@@ -624,23 +658,29 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
}
if (labelCtx && labelCanvas) {
- labelCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
- labelCtx.clearRect(0, 0, cssW, cssH);
+ labelCtx.setTransform(1, 0, 0, 1, 0, 0);
+ labelCtx.clearRect(0, 0, labelCanvas.width, labelCanvas.height);
if (zoom >= 0.45 && Array.isArray(labels) && labels.length > 0) {
+ // Draw in device pixels so glyph AA matches the HiDPI canvas.
+ const fontPx = Math.max(12, Math.round(12 * dpr));
labelCtx.textAlign = "center";
labelCtx.textBaseline = "top";
- labelCtx.font = "600 11px ui-sans-serif, system-ui, sans-serif";
+ labelCtx.font = `500 ${fontPx}px Inter, system-ui, -apple-system, "Segoe UI", sans-serif`;
+ labelCtx.imageSmoothingEnabled = true;
const fill = dark ? "#f4f4f5" : "#18181b";
- const stroke = dark ? "rgba(9,9,11,0.85)" : "rgba(255,255,255,0.9)";
+ const stroke = dark ? "rgba(9,9,11,0.75)" : "rgba(255,255,255,0.88)";
+ labelCtx.lineJoin = "round";
+ labelCtx.miterLimit = 2;
+ labelCtx.lineWidth = Math.max(2, Math.round(2.25 * dpr));
for (const lab of labels) {
if (!lab?.text) continue;
const sx = (lab.x - camX) * zoom + cssW * 0.5;
const sy = (lab.y - camY) * zoom + cssH * 0.5;
if (sx < -40 || sy < -20 || sx > cssW + 40 || sy > cssH + 20) continue;
const r = Math.max(lab.size || 10, 6) * zoom;
- const tx = sx;
- const ty = sy + r + 3;
- labelCtx.lineWidth = 3;
+ // Snap to device pixels to avoid blurry half-pixel text.
+ const tx = Math.round(sx * dpr);
+ const ty = Math.round((sy + r + 4) * dpr);
labelCtx.strokeStyle = stroke;
labelCtx.fillStyle = fill;
labelCtx.strokeText(lab.text, tx, ty);
diff --git a/tests/backend/test_rnsh_api.py b/tests/backend/test_rnsh_api.py
index d8d6898d..30f45cd5 100644
--- a/tests/backend/test_rnsh_api.py
+++ b/tests/backend/test_rnsh_api.py
@@ -483,6 +483,24 @@ def test_rnsh_stop_returns_stopped_status(monkeypatch):
assert session.status == RNSHSession.STATUS_STOPPED
+def test_rnsh_build_env_redirects_home_under_storage(tmp_path):
+ """rnsh ensure_config_directory needs ~/.rnsh. Keep it inside storage."""
+ from meshchatx.src.backend.rnsh_manager import RNSHSession
+
+ storage = tmp_path / "identity_storage"
+ storage.mkdir()
+ manager = MagicMock()
+ manager.storage_dir = str(storage)
+ manager.reticulum_config_dir = str(tmp_path / "reticulum")
+ session = RNSHSession(manager, "s1", {"mode": "listen"})
+ env = session._build_env()
+ expected_home = str(storage / "rnsh_home")
+ assert env["HOME"] == expected_home
+ assert (storage / "rnsh_home" / ".rnsh").is_dir()
+ assert "XDG_CONFIG_HOME" not in env
+ assert env.get("PYTHONUNBUFFERED") == "1"
+
+
def test_rnsh_waiter_does_not_clobber_restarted_process():
import threading
import time
diff --git a/tests/backend/test_rnsh_live.py b/tests/backend/test_rnsh_live.py
index 344f1b4d..9c5633bd 100644
--- a/tests/backend/test_rnsh_live.py
+++ b/tests/backend/test_rnsh_live.py
@@ -16,6 +16,7 @@ import importlib.util
import os
import shutil
import socket
+import sys
import tempfile
import textwrap
import time
@@ -33,8 +34,10 @@ _RNSH_AVAILABLE = importlib.util.find_spec(_RNSH_MODULE) is not None
class _LiveManager:
- def __init__(self, reticulum_config_dir: str):
+ def __init__(self, reticulum_config_dir: str, storage_dir: str | None = None):
self.reticulum_config_dir = reticulum_config_dir
+ # rnsh subprocess HOME is rooted under storage_dir (Landlock RW tree).
+ self.storage_dir = storage_dir or reticulum_config_dir
self.changes = 0
self.outputs = 0
@@ -98,6 +101,9 @@ def test_rnsh_live_listen_session_reports_address(monkeypatch):
assert "-m" in started["last_command"] or "rnsh" in started["last_command"]
assert "--meshchatx-run-module" not in started["last_command"]
assert session._build_env().get("PYTHONUNBUFFERED") == "1"
+ env = session._build_env()
+ assert env["HOME"] == os.path.join(tmpdir, "rnsh_home")
+ assert os.path.isdir(os.path.join(tmpdir, "rnsh_home", ".rnsh"))
address = _wait_for_listen_address(session)
assert len(address) >= 16
@@ -347,3 +353,285 @@ def test_rnsh_live_listen_connect_echo_roundtrip():
listen.stop()
shutil.rmtree(listen_dir, ignore_errors=True)
shutil.rmtree(conn_dir, ignore_errors=True)
+
+
+def _free_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+def _write_isolated_rns_config(config_dir: str, *, port: int, name: str) -> None:
+ os.makedirs(config_dir, exist_ok=True)
+ path = os.path.join(config_dir, "config")
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write(
+ textwrap.dedent(
+ f"""\
+ [reticulum]
+ enable_transport = No
+ share_instance = No
+ shared_instance_port = {port}
+ instance_name = {name}
+ panic_on_interface_error = No
+
+ [logging]
+ loglevel = 6
+ """,
+ ),
+ )
+
+
+@pytest.mark.integration
+@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RNSH=1 to run live RNSh tests")
+@pytest.mark.skipif(
+ not _RNSH_AVAILABLE,
+ reason="RNS.Utilities.rnsh.rnsh is not installed",
+)
+@pytest.mark.skipif(sys.platform != "linux", reason="Landlock live check is Linux-only")
+def test_rnsh_live_listen_without_landlock(monkeypatch):
+ """Landlock off: listener starts and HOME stays under storage."""
+ monkeypatch.setenv("MESHCHAT_LANDLOCK", "0")
+ _force_pipe_mode(monkeypatch)
+ storage = tempfile.mkdtemp(prefix="meshchat_rnsh_ll_off_storage_")
+ rns_dir = tempfile.mkdtemp(prefix="meshchat_rnsh_ll_off_rns_")
+ session = None
+ try:
+ _write_isolated_rns_config(rns_dir, port=_free_port(), name="rnsh_ll_off")
+ manager = _LiveManager(rns_dir, storage_dir=storage)
+ session = RNSHSession(
+ manager,
+ "live-ll-off",
+ {
+ "mode": "listen",
+ "no_auth": True,
+ "quiet": 1,
+ "config_path": rns_dir,
+ },
+ )
+ started = session.start()
+ assert started["status"] == RNSHSession.STATUS_RUNNING
+ env = session._build_env()
+ assert env["HOME"] == os.path.join(storage, "rnsh_home")
+ address = _wait_for_listen_address(session, timeout=35.0)
+ assert len(address) >= 16
+ assert "Could not get or create rnsh configuration directory" not in (
+ session.to_dict(include_output_tail=True).get("output_text") or ""
+ )
+ finally:
+ if session is not None:
+ with contextlib.suppress(Exception):
+ session.stop()
+ shutil.rmtree(storage, ignore_errors=True)
+ shutil.rmtree(rns_dir, ignore_errors=True)
+
+
+@pytest.mark.integration
+@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RNSH=1 to run live RNSh tests")
+@pytest.mark.skipif(
+ not _RNSH_AVAILABLE,
+ reason="RNS.Utilities.rnsh.rnsh is not installed",
+)
+@pytest.mark.skipif(sys.platform != "linux", reason="Landlock live check is Linux-only")
+def test_rnsh_live_listen_under_landlock_and_denied_home_fails():
+ """Landlock on + real HOME outside RW roots: rnsh cannot create ~/.rnsh."""
+ import subprocess
+ import sys
+ from pathlib import Path
+
+ from meshchatx.src.backend.landlock_sandbox import landlock_kernel_supported
+
+ if not landlock_kernel_supported():
+ pytest.skip("Landlock not available on this kernel")
+
+ storage = tempfile.mkdtemp(prefix="meshchat_rnsh_ll_deny_storage_")
+ rns_dir = tempfile.mkdtemp(prefix="meshchat_rnsh_ll_deny_rns_")
+ _write_isolated_rns_config(rns_dir, port=_free_port(), name="rnsh_ll_deny")
+ script = textwrap.dedent(
+ f"""\
+ import os
+ import subprocess
+ import sys
+ from meshchatx.src.backend.landlock_sandbox import apply_landlock_sandbox
+
+ storage = {storage!r}
+ rns_dir = {rns_dir!r}
+ os.environ["MESHCHAT_LANDLOCK"] = "1"
+ # HOME outside the Landlock RW tree (storage/tmp/run/dev).
+ os.environ["HOME"] = "/root"
+ os.environ.pop("XDG_CONFIG_HOME", None)
+ ok = apply_landlock_sandbox(
+ storage_dir=storage,
+ reticulum_config_dir=rns_dir,
+ log_dir=storage,
+ )
+ if not ok:
+ print("LANDLOCK_NOT_APPLIED")
+ sys.exit(2)
+ env = dict(os.environ)
+ env["PYTHONUNBUFFERED"] = "1"
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "RNS.Utilities.rnsh.rnsh",
+ "-c",
+ rns_dir,
+ "-l",
+ "-n",
+ "-q",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=12,
+ env=env,
+ check=False,
+ )
+ out = (result.stdout or "") + (result.stderr or "")
+ print("RC", result.returncode)
+ print("OUT", out[-2000:])
+ if "Could not get or create rnsh configuration directory" in out:
+ print("GOT_CONFIG_DIR_CRITICAL")
+ sys.exit(0)
+ # Some environments may fail earlier with PermissionError on /root.
+ if result.returncode not in (0, None) and (
+ "Permission" in out or "configuration directory" in out
+ ):
+ print("GOT_HOME_DENY")
+ sys.exit(0)
+ print("UNEXPECTED_SUCCESS")
+ sys.exit(3)
+ """,
+ )
+ try:
+ result = subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=str(Path(__file__).resolve().parents[2]),
+ capture_output=True,
+ text=True,
+ timeout=40,
+ check=False,
+ )
+ if "LANDLOCK_NOT_APPLIED" in result.stdout:
+ pytest.skip("Landlock could not be applied in this environment")
+ assert result.returncode == 0, (result.stdout, result.stderr)
+ assert (
+ "GOT_CONFIG_DIR_CRITICAL" in result.stdout
+ or "GOT_HOME_DENY" in result.stdout
+ )
+ finally:
+ shutil.rmtree(storage, ignore_errors=True)
+ shutil.rmtree(rns_dir, ignore_errors=True)
+
+
+@pytest.mark.integration
+@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RNSH=1 to run live RNSh tests")
+@pytest.mark.skipif(
+ not _RNSH_AVAILABLE,
+ reason="RNS.Utilities.rnsh.rnsh is not installed",
+)
+@pytest.mark.skipif(sys.platform != "linux", reason="Landlock live check is Linux-only")
+def test_rnsh_live_listen_under_landlock_with_storage_home():
+ """Landlock on + HOME under storage: MeshChatX-style env lets rnsh listen."""
+ import subprocess
+ import sys
+ from pathlib import Path
+
+ from meshchatx.src.backend.landlock_sandbox import landlock_kernel_supported
+
+ if not landlock_kernel_supported():
+ pytest.skip("Landlock not available on this kernel")
+
+ storage = tempfile.mkdtemp(prefix="meshchat_rnsh_ll_ok_storage_")
+ rns_dir = tempfile.mkdtemp(prefix="meshchat_rnsh_ll_ok_rns_")
+ _write_isolated_rns_config(rns_dir, port=_free_port(), name="rnsh_ll_ok")
+ script = textwrap.dedent(
+ f"""\
+ import os
+ import sys
+ import time
+ from meshchatx.src.backend.landlock_sandbox import apply_landlock_sandbox
+ from meshchatx.src.backend.rnsh_manager import RNSHSession
+
+ storage = {storage!r}
+ rns_dir = {rns_dir!r}
+ os.environ["MESHCHAT_LANDLOCK"] = "1"
+ ok = apply_landlock_sandbox(
+ storage_dir=storage,
+ reticulum_config_dir=rns_dir,
+ log_dir=storage,
+ )
+ if not ok:
+ print("LANDLOCK_NOT_APPLIED")
+ sys.exit(2)
+
+ class Manager:
+ def __init__(self):
+ self.storage_dir = storage
+ self.reticulum_config_dir = rns_dir
+ def _on_session_change(self, _s):
+ return None
+ def _on_session_output(self, _s, _c):
+ return None
+ def save(self):
+ return None
+
+ # Avoid PTY SIGHUP under capture.
+ RNSHSession._supports_pty = staticmethod(lambda: False)
+ session = RNSHSession(
+ Manager(),
+ "live-ll-ok",
+ {{
+ "mode": "listen",
+ "no_auth": True,
+ "quiet": 1,
+ "config_path": rns_dir,
+ }},
+ )
+ env = session._build_env()
+ print("HOME", env.get("HOME"))
+ if env.get("HOME") != os.path.join(storage, "rnsh_home"):
+ print("BAD_HOME")
+ sys.exit(4)
+ started = session.start()
+ print("STATUS", started.get("status"))
+ print("PID", started.get("pid"))
+ deadline = time.time() + 35.0
+ while time.time() < deadline:
+ if session.listen_address:
+ print("LISTEN", session.listen_address)
+ text = session.to_dict(include_output_tail=True).get("output_text") or ""
+ if "Could not get or create rnsh configuration directory" in text:
+ print("CONFIG_DIR_CRITICAL")
+ session.stop()
+ sys.exit(5)
+ session.stop()
+ print("OK")
+ sys.exit(0)
+ if session.status == "failed":
+ print("FAILED", session.last_error)
+ print("OUT", session.to_dict(include_output_tail=True).get("output_text"))
+ sys.exit(6)
+ time.sleep(0.2)
+ print("TIMEOUT", session.to_dict(include_output_tail=True).get("output_text"))
+ session.stop()
+ sys.exit(7)
+ """,
+ )
+ try:
+ result = subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=str(Path(__file__).resolve().parents[2]),
+ capture_output=True,
+ text=True,
+ timeout=60,
+ check=False,
+ )
+ if "LANDLOCK_NOT_APPLIED" in result.stdout:
+ pytest.skip("Landlock could not be applied in this environment")
+ assert result.returncode == 0, (result.stdout, result.stderr)
+ assert "OK" in result.stdout
+ assert "LISTEN" in result.stdout
+ finally:
+ shutil.rmtree(storage, ignore_errors=True)
+ shutil.rmtree(rns_dir, ignore_errors=True)
diff --git a/tests/frontend/networkVisualiserNodeLook.test.js b/tests/frontend/networkVisualiserNodeLook.test.js
index 1cc0278d..bf0f6b23 100644
--- a/tests/frontend/networkVisualiserNodeLook.test.js
+++ b/tests/frontend/networkVisualiserNodeLook.test.js
@@ -7,6 +7,7 @@
import { describe, expect, it } from "vitest";
import {
+ ATLAS_CELL,
isGlyphStyleVisualiserIcon,
NODE_BORDER_INNER,
NODE_BORDER_OUTER,
@@ -38,6 +39,10 @@ describe("visualiser WebGL node look regressions", () => {
expect(isGlyphStyleVisualiserIcon("")).toBe(false);
});
+ it("uses a high-res atlas cell so icons stay sharp on HiDPI", () => {
+ expect(ATLAS_CELL).toBeGreaterThanOrEqual(128);
+ });
+
it("extracts white glyph and clears solid fill from badge pixels", () => {
// 2x2: blue fill, white glyph, blue fill, empty
const data = new Uint8ClampedArray([
@@ -69,6 +74,18 @@ describe("visualiser WebGL node look regressions", () => {
expect(data[11]).toBe(0);
});
+ it("preserves soft alpha on mid-luma glyph AA fringes", () => {
+ // Grey fringe between white glyph and blue fill (binary threshold would crunch this).
+ const data = new Uint8ClampedArray([180, 180, 180, 255]);
+ const { glyphPixels } = prepareVisualiserIconPixels(data, "glyph");
+ expect(glyphPixels).toBe(1);
+ expect(data[0]).toBe(255);
+ expect(data[1]).toBe(255);
+ expect(data[2]).toBe(255);
+ expect(data[3]).toBeGreaterThan(40);
+ expect(data[3]).toBeLessThan(220);
+ });
+
it("opaque mode forces alpha on RGB pixels for logo uploads", () => {
const data = new Uint8ClampedArray([10, 20, 30, 0, 0, 0, 0, 0]);
const { painted } = prepareVisualiserIconPixels(data, "opaque");
diff --git a/tests/frontend/networkVisualiserWebGLEngine.test.js b/tests/frontend/networkVisualiserWebGLEngine.test.js
index 391b9765..6453f46c 100644
--- a/tests/frontend/networkVisualiserWebGLEngine.test.js
+++ b/tests/frontend/networkVisualiserWebGLEngine.test.js
@@ -82,6 +82,7 @@ function stubGl() {
texParameteri: vi.fn(),
texImage2D: vi.fn(),
texSubImage2D: vi.fn(),
+ generateMipmap: vi.fn(),
pixelStorei: vi.fn(),
deleteTexture: vi.fn(),
deleteBuffer: vi.fn(),
@@ -104,6 +105,7 @@ function stubGl() {
RGBA: 0x1908,
UNSIGNED_BYTE: 0x1401,
LINEAR: 0x2601,
+ LINEAR_MIPMAP_LINEAR: 0x2703,
CLAMP_TO_EDGE: 0x812f,
TEXTURE_MIN_FILTER: 0x2801,
TEXTURE_MAG_FILTER: 0x2800,
@@ -146,9 +148,9 @@ function makeCanvas(gl) {
clearRect: vi.fn(),
drawImage: vi.fn(),
getImageData: () => ({
- data: new Uint8ClampedArray(64 * 64 * 4).fill(255),
- width: 64,
- height: 64,
+ data: new Uint8ClampedArray(128 * 128 * 4).fill(255),
+ width: 128,
+ height: 128,
}),
putImageData: vi.fn(),
save: vi.fn(),
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────